iT邦幫忙

2023 iThome 鐵人賽

DAY 4
3

簡介

除了 ChatGPT 網頁介面以外,還能透過 OpenAI API 調用 ChatGPT 的功能,讓我們能夠在自己的開發應用裡面借助 ChatGPT 的力量。以下介紹 ChatGPT 的各種使用眉角。

可愛貓貓 Day 4

(Powered By Microsoft Designer)

Token

在開始介紹 ChatGPT API 之前,必須先介紹 Token 這個概念。在 NLP 領域裡面 Token 是切割文本的最小單位,有些中文翻譯成「詞元」。可以想像是字或詞的概念,而在 BPE (Byte Pair Encoding) Tokenizer 底下,通常會切的更細一點。

一句話會如何被分詞 (Tokenize) 取決於分詞器 (Tokenizer) 如何被訓練,並不一定是依照空格或字元邊界切開。例如在處理單字 "photography" 時,可能會被拆成 "photo" 和 "graphy" 兩個 Subwords,因此它會佔用兩個 Tokens。

在 UTF-8 裡面中日韓表意文字 (CJK Characters) 通常由 3 個 Bytes 所組成。因為中日韓的文字相當多,通常不會全部包含在 BPE Tokenizer 的 Vocabulary 裡面,因此經常需要將沒見過的 UTF-8 字元 Fallback 拆解成 Byte 來表示。

一般情況下,中文(或中日韓文字)消耗的 Token 用量會比英文多的多,因為中文通常要花費比較多 Byte 來表示一個同義的英文單字。例如在英文中 "apple" 通常只需要 1 個 Token 來表示,而蘋果則可能需要 4 ~ 6 個 Tokens 來表示。

Tiktoken

Tiktoken 是 OpenAI 的 Tokenizer 套件,這名稱估計是來致敬 TikTok 抖音的,同樣也是使用 BPE Tokenizer。在操作 ChatGPT API 的過程,我們需要透過 OpenAI 官方的 Tokenizer 來精準控制 Token 用量,避免超出模型的輸入長度,或者預防使用者任意輸入過長的句子。以下是一個使用範例:

import tiktoken

tk = tiktoken.encoding_for_model("gpt-3.5-turbo")

token_ids = tk.encode("今天太陽很大")
print(len(token_ids))  # 輸出 9

token_ids = tk.encode("The sun is very bright today")
print(len(token_ids))  # 輸出 6

可以看到,雖然是 6 個中文字,卻用了 9 個 Tokens。若我們將這句話翻譯成英文 "The sun is very bright today" 並計算 Token 數量,會發現只需要 6 個 Tokens。我們進一步來探討 Tokenizer 都把這些字詞切成什麼樣子:

for token in tokenizer.encode("photography攝影"):
    b = tokenizer.decode_bytes([token])
    print(b)

# Output: b'phot', b'ography', b'\xe6\x94', b'\x9d', b'\xe5\xbd\xb1'

觀察此輸出,我們可以看到:

  1. "photography" 被拆成了 "phot" 與 "ography" 兩個 Subwords。
  2. 「攝」這個字則被拆解成 \xe6\x94\x9d 兩組 Byte 的組合。
  3. 「影」被完整保留為 \xe5\xbd\xb1 的編碼。

photography 直覺上可能會認為應該被切成 "photo" 與 "graphy",但這就是 BPE Tokenizer 訓練階段自己統計出來的結果,切成 "phot" 與 "ography" 可能更貼近訓練語料的分佈。

在 BPE Tokenizer 的訓練過程中,為了減少字典大小,部分不常見的 UTF-8 字元會被拆解成 Bytes。這類做法的好處是,在某個程度上避免了中文斷詞錯誤引起的問題。中文斷詞在傳統 NLP 上真的非常令人困擾。這樣的分詞法很大程度的降低這個問題,也具有處理未知詞的能力,然而代價是更龐大的參數量與更難收斂的模型。因此,一些以中文為主的語言模型,可能會考慮擴大其字典,納入更多中文字,這樣可以減少中文 Token 的使用量。

透過 Tiktoken 套件,我們也能比較一下 GPT-3.5 與 GPT-4 Tokenizer 的差異:

tk1 = tiktoken.encoding_for_model("gpt-3.5-turbo")
tk2 = tiktoken.encoding_for_model("gpt-4")
print(tk1, tk2)

將這兩個 Tokenizer 印出來,會發現他們都是 <Encoding 'cl100k_base'>,原來是因為這兩個模型根本使用相同的 Tokenizer,而這個 Tokenizer 的名稱是 cl100k_base。因此這部份與 tiktoken.get_encoding("cl100k_base") 的操作是等價的,詳細的 Tokenizer 名稱可以參考官方範例

Encoding Name OpenAI Models
cl100k_base gpt-4, gpt-3.5-turbo, text-embedding-ada-002
p50k_base Codex models, text-davinci-002, text-davinci-003
r50k_base (or gpt2) GPT-3 models like davinci

若要相當精準的考慮 Chat Format 對 Token 數量的影響,可以參考官方的 ChatGPT Prompt 格式範例。其關鍵在於每次 ChatGPT 回覆都會增加 <|start|>assistant<|message|> 這三個固定前綴 Tokens,因此 Token 消耗量需要額外 +3 上去。

Pricing

為什麼這個 Token 這麼重要呢,除了對模型效能的影響以外,對我們開發者的荷包也有很大的影響 💸

ChatGPT API 本身的計價方式,就是以 Token 數量計價的。詳細的計價方式,請參考官方網站,我們這邊以 GPT-3.5 Turbo 的價格為例:

Model Input Output
4K Context $0.0015/1K Tokens $0.002/1K Tokens
16K Context $0.0030/1K Tokens $0.004/1K Tokens

註:此價格為 2023 年 9 月份的價格,價格經常變動,請以官方網站為準。

4K 與 16K 分別代表不同長度上限的模型種類,長度上限越高,收費越貴。而收費方式也區分為輸入與輸出兩種。其中 4K 的 Input 為 $0.0015/1K Tokens,也就是說每輸入 1000 個 Tokens 就要收費 0.0015 鎂,約新台幣 0.04 ~ 0.05 塊錢。假設每個中文字平均佔用 2 ~ 3 個 Tokens,那 1000 個 Tokens 約莫是 300 ~ 500 個中文字左右。這是 Input 的情況,在 Output 這邊又稍微貴一些。

這個價格到底算不算便宜呢?筆者手邊有個小應用,日均 150 個 Requests 左右,每個 Requests 約需消耗 1000 個 Tokens 左右,月結帳單約落在 7 至 9 鎂之間。對我這種小型開發者而言,月均成本不到新台幣 300 元,是相當小的一筆開銷,只需要一點簡單的營利手段就很容易打平甚至獲利。

這邊要特別注意:使用 GPT-3GPT-3.5 的價格差距是很大的!GPT-3 的價格是 $0.02/1K Tokens,足足是 GPT-3.5 的十倍有餘!根據官方所述 GPT-3 已經是即將被棄用的模型,其效果與 GPT-3.5 差距是很大的。但有些比較早期的專案還是在使用 GPT-3 (text-davinci-003),因此在套用別人的專案時,要格外注意這點。

根據官方文件所述,建議使用 gpt-3.5-turbo-instruct 取代 Completions API 的 text-davinci-003 模型。

ChatGPT API

在使用 API 之前,需要先註冊帳號並取得 API 金鑰。如果是新張號的話,前三個月有 5 鎂的免費額度可以用。但如果你的帳號已經辦一陣子了,那就需要綁信用卡花點小錢才能用了。

使用 API 時,建議多多參考官方文件。筆者主要使用 Python 開發,因此需要安裝 Python 版的 OpenAI API 套件:

pip install openai

認證

可以透過以下方式進行認證:

import openai

openai.api_key = "sk-...xxxx"

但是在程式碼裡面寫死 API Key 顯然不是個好選擇,替代方案除了常用的環境變數以外,也可以將 API Key 存在檔案裡面,並指定 API Key 的路徑:

openai.api_key_path = "API.Key"

另外,如果你是有加入公司組織之類的,記得要設定 Organization 資訊:

openai.organization = "org-...xxxx"

不然帳單就不是報公帳,而是算在你頭上囉~

基礎用法 Non-Streaming

完成認證後,我們就可以開始串接 ChatGPT API 了!以下是個基本範例:

import openai

openai.api_key_path = "API.Key"

response = openai.ChatCompletion.create(
    model="gpt-3.5-turbo",
    messages=[
        {"role": "system", "content": "請使用繁體中文回答。"},
        {"role": "user", "content": "你好啊!"},
    ],
)

print(response["choices"][0]["message"]["content"])
# 輸出結果:你好!有什麼我可以幫助你的嗎?

透過 model 參數指定要用哪個模型,除了 gpt-3.5-turbo 以外,還有 gpt-4 以及各自不同的長度和日期版本,例如 gpt-4-32k-0613 就代表是輸入長度支援到 32K 且釋出日期為 6 月 13 號的 GPT-4 模型。模型代號經常有變動,請以官方網站宣布的為主。

要傳送給模型處理的訊息透過 messages 參數傳遞,這個參數接收一個陣列,陣列裡面每個元素代表每個回合的對話內容。在對話內容裡面可以設定 role 來代表該訊息的角色,主要有 system, user, assistant 三種角色。其中 system 就是昨天提到的 System Prompt 概念,而 userassistant 分別代表使用者的輸入與模型的輸出。

我們可以透過一些額外的參數來控制生成的結果,例如 max_tokens 可以設定最多輸出幾個 Tokens,參數 stop 可以設定模型輸出遇到什麼字串需要停下來。此外還有 temperature, top_p 和各種 Penalty 等取樣參數,這在未來會詳細談到。

串流用法 Streaming

加上參數 stream=True 即可用串流的方式接收輸出,而 response 也會因此變成一個 Generator 物件:

response = openai.ChatCompletion.create(
    model="gpt-3.5-turbo",
    messages=[
        {"role": "system", "content": "請使用繁體中文回答。"},
        {"role": "user", "content": "你好啊!"},
    ],
    stream=True,
)

for resp in response:
    try:
        print(end=resp["choices"][0]["delta"]["content"], flush=True)
    except:
        pass
print()

這樣就會看到模型輸出一個字一個字的跑出來啦!用 Streaming 的方式顯示輸出,比較能感受到模型跟連線都還「活著」的感覺。最終要不要使用 Streaming 輸出,取決於應用層是否需要將輸出顯示給使用者看。如果使用者會直接接觸到這些輸出,那有 Streaming 會讓使用者比較感受的到回饋。如果單純只是應用端的邏輯處理,那只需要使用 Non-Streaming 即可。

OpenAI 回傳的格式其實涵蓋的資訊蠻多的,而 delta/content 這個位置不一定每次都有。筆者嘗試了多種方法避免 try-except 的語法,研究到最後的心得是:用 try-except 最快 QQ

CLI Chat Demo

我們可以將使用者輸入與模型輸出不斷加到 messages 裡面來達到多輪對話的效果。但如果訊息太長或者要做成本控制,我們就需要結合 Tiktoken 套件來截斷輸入。在截斷的過程記得保留系統提示,並且從最舊的訊息開始截斷。基於這個邏輯,我們可以製作一個簡單的文字聊天介面,完整程式碼如下:

import openai
import tiktoken

openai.api_key_path = "API.Key"
tk = tiktoken.encoding_for_model("gpt-3.5-turbo")


def truncate(messages, limit=300):
    """
    我們從訊息的尾端往前拜訪,不斷累加總 Token 數
    直到總 Token 數超過限制,最後將 System Prompt 加回訊息裡面
    """
    total = 0
    new_messages = list()
    for msg in reversed(messages[1:]):
        total += len(tk.encode(msg["content"]))
        if total > limit:
            break
        new_messages.insert(0, msg)
    new_messages.insert(0, messages[0])
    return new_messages


def chat(messages):
    return openai.ChatCompletion.create(
        model="gpt-3.5-turbo",
        messages=messages,
        stream=True,
    )


def show(response):
    # 使用 Streaming 的方式顯示模型的輸出
    full_resp = str()
    print(end="Assistant: ", flush=True)
    for resp in response:
        try:
            token = resp["choices"][0]["delta"]["content"]
            print(end=token, flush=True)
            full_resp += token
        except:
            pass
    print()
    return full_resp


messages = [{"role": "system", "content": "你現在是一個使用繁體中文的貓娘。"}]

while True:
    prompt = input("User: ").strip()
    messages.append({"role": "user", "content": prompt})

    messages = truncate(messages)
    response = chat(messages)
    response = show(response)

    # 將模型輸出加入歷史訊息
    messages.append({"role": "assistant", "content": response})

使用起來的狀況大致如下:

User: 你好啊
Assistant: 嗨!你好啊!有什麼我可以幫助你的嗎?
User: 請你簡短的自我介紹一下
Assistant: 嗨!我是一隻習慣使用繁體中文的貓娘 ...
User: 請問貓咪如何清潔自己
Assistant: 貓咪通常會利用自己的舌頭和腳來進行清潔 ...

透過幾行簡單的程式碼,我們成功打造了一隻可愛貓娘助手 ((誤

Embedding API

Embedding 在 NLP 裡面扮演相當重要的角色,尤其在資訊檢索 (Information Retrieval) 領域裡面相當實用。而 OpenAI 也提供 Embedding API 讓使用者可以將文本轉成 Sentence Embedding 來使用,以下是一個簡單範例:

response = openai.Embedding.create(
    model="text-embedding-ada-002",
    input=["我喜歡貓咪", "來去看電影"],
)

for item in response["data"]:
    embeddings = item["embedding"]
    print(len(embeddings), embeddings[:4])

"""
輸出結果:
1536 [-0.01141282077,  0.01442165579, -0.009603629820, -0.03597632423]
1536 [-0.00960167869, -0.03483279421, -0.015344557352, -0.04107420891]
"""

Ada v2 最大輸入長度可以達到 8192 個 Tokens,每份 Embedding 的維度是 1536 維。Embedding API 可以同時處理很多個句子,減少傳送處理的時間,因此會比你一句一句取 Embedding 快的多。這種 Embedding 最常見的用法就是比對文本之間的相似度,例如透過計算 Embedding 之間的歐式距離 (Euclidean Distance) 來觀察跨語言文本之間的相似度如何:

import numpy as np
import openai
from sklearn.metrics.pairwise import euclidean_distances

openai.api_key_path = "API.Key"

value_list = ["我喜歡貓咪", "來去看電影"]
response = openai.Embedding.create(input=value_list, model="text-embedding-ada-002")

values = [item["embedding"] for item in response["data"]]
values = np.array(values)

query_list = ["i like cats", "watch a movie", "猫が好き", "映画を見に来てください"]
response = openai.Embedding.create(input=query_list, model="text-embedding-ada-002")

print(value_list)
for i, item in enumerate(response["data"]):
    embeddings = item["embedding"]
    print(euclidean_distances([embeddings], values)[0], query_list[i])

"""
稍微經過人工排版的輸出結果:

["我喜歡貓咪", "來去看電影"]
[  0.49925489    0.72721379] i like cats
[  0.77279439    0.54520915] watch a movie
[  0.50884729    0.68798437] 猫が好き
[  0.72506646    0.50975780] 映画を見に来てください
"""

因為我們是計算歐式距離的關係,所以數字越小代表越相近,也可以換成 Cosine Similarity 之類的評估公式。可以看到 Embedding 不僅能用來比較文本之間的相似度,也具有跨語言的能力,在未來提到 Retrieval-Based 應用時會相當重要。

另外 Embedding API 的 Ada v2 只要 $0.0001/1K Tokens 超便宜!

Rate Limits

在使用 Open API 時,其用量除了受限於開發者的荷包以外,官方也有限制 API 的存取速率。詳細資訊請參考官方網站,在文字與 Embedding 部分,常用的單位為 Requests Per Minute (RPM) 與 Tokens Per Minute (TPM) 兩種。RPM 是指每分鐘可以存取 API 的次數上限,而 TPM 則是代表每分鐘可以要求 API 處理的 Token 數量上限。

例如使用 ChatGPT API 時,如果發送大量短訊息,那就有可能先踩到 RPM 的上限。而在使用 Embedding API 時,如果每次 Request 都發送很大量的文本,那就有可能先踩到 TPM 的上限。

根據我的理解,使用量達到任何一個限制都會先被擋下來,但是在合理的使用下,官方並不會把你封鎖起來,而是回傳一個達到速率上限的錯誤訊息。

這種情況在做實驗時很容易遇到,因為 ChatGPT 一筆一筆生答案其實是挺慢的,因此有些實驗可能會透過 Multi-Threading 的方式發送 Requests,記得在裡面捕捉超速錯誤並延遲一段時間再送一次。

為了展示這個速率限制,筆者自掏腰包,以 0.0004 鎂(約新台幣 0.012 塊錢)的代價,以 Embedding API 的 3500 RPM 為例,測試了一分鐘內發四千個 Embedding API 的 Requests 給大家看:

import time
from concurrent.futures import ThreadPoolExecutor
from threading import Lock

import openai
from openai.error import RateLimitError

openai.api_key_path = "API.Key"

results = dict()
lock = Lock()


def create_request(thread_id):
    print(f"Thread {thread_id} Begin")
    while True:
        try:
            response = openai.Embedding.create(
                model="text-embedding-ada-002",
                input=["0"],
            )
            break
        except RateLimitError as e:
            print(f"Rate Limit Error! Wait For 5 Seconds ...")
            time.sleep(5)

    with lock:
        results[thread_id] = response
        current_length = len(results)
    print(f"Thread {thread_id} Done {current_length}")


thread_ids = range(1)  # 為了避免有人直接拿來跑,請自行將數字改成 4000 做測試

with ThreadPoolExecutor(max_workers=128) as executor:
    executor.map(create_request, thread_ids)

!! 注意 !! 請不要輕易嘗試以上程式碼,可能會造成不可預期的開銷。

Billing

這個程式在運作的過程中,會不斷拋出 Error: Rate limit reached for ... 的錯誤訊息,此 Exception 類別為 openai.error.RateLimitError。以上作法僅供參考,也可以使用其他有 retry 功能的套件來處理,這部份可以參考官方文件的推薦。

API Usage

開發者可以到 OpenAI 的 Usage 頁面查看 API 的使用量與目前累積的花費。下方的 Daily usage breakdown (UTC) 可以查詢詳細的 Request 資訊,包含各時間區段的 Request 次數與 Token 用量,以及使用了哪個模型之類的。這個用量的更新約略會延遲個五分鐘左右,大標寫的時間基本上是 UTC+0 的時間,但是點開詳細資訊可以看到 Local Time 本地時間。

Detailed Request

如果整合好 API 的應用正式上線後,可以透過此頁面監控使用量與計費,並分析應用的成本。

Bonus

OpenAI 其實不只有提供語言模型的服務,還有 Whisper 語音辨識以及 DALL-E 圖像生成等功能。這兩個功能也是相當實用,所以筆者也順便介紹一下這些 API 的用法。

Whisper

Whisper 是 OpenAI 訓練的一個語音轉文字 (Speech To Text, STT) 模型,採用 Encoder-Decoder 的 Transformer 架構。具有跨語言辨識的能力,同樣是 OpenAI API 的其中一項服務,其使用方式為上傳音檔。

音檔格式的支援滿廣泛的,最高上傳 25 MB 大小的音檔。筆者建議轉成 Mp3 格式,比較節省流量也能減少網路傳輸時間。可以透過 FFmpeg 轉換格式,例如:

ffmpeg -i audio.wav audio.mp3

以下是一個簡單的使用範例:

import openai

openai.api_key_path = "API.Key"

audio = open("audio.mp3", "rb")
transcript = openai.Audio.transcribe("whisper-1", audio)
print(transcript["text"])

Whisper API 的辨識速度相當的快,600 秒的音檔只要 30 秒就能完成辨識,是 Real-Time 的 20 倍快,速度相當驚人。筆者實際使用 RTX 3090 做辨識,約莫也是 8 ~ 10 倍快而已。

Whisper 除了能做傳統的語音辨識以外,還能直接將語音翻譯成其他語言。但目前官方的 API 只支援翻譯成英文,這裡就不特別做介紹了。

Whisper API 的計價方式是每辨識一分鐘的音檔便收取 $0.006 鎂的費用,辨識十分鐘的音檔約莫就是新台幣 1 ~ 2 塊錢而已,拿來幫喜歡的 VTuber 製作影片逐字稿蠻方便的

但如果你真的想客家一點,其實 Whisper 的模型權重是有開源的!相比於 ChatGPT 這種語言模型而言,語音辨識的權重要來的小很多。需要佔用的 GPU Memory 約 6 GB 左右,若擁有不錯的 GPU 可以嘗試自己運行 Whisper 模型,在需求量不大的情況下會相對經濟一點,以下是一些可以參考的資源:

  • HuggingFace
    • OpenAI 官方公佈的模型權重。
  • whisper.cpp
    • 使用 GGML 框架的 Whisper 實做,主要使用 C++ 串接。
  • Whisper Desktop
    • 基於 whisper.cpp 製作的程式,推薦給 Windows 使用者。
  • Faster Whisper
    • 基於 CTranslate2 的 Whisper 實做,可以用 Python 串接。

DALL-E

DALL-E 是 OpenAI 的圖像生成模型,雖然知名度可能不如 Stable Diffusion 之類的,但也是個可供參考的服務。以下是個簡單的範例程式:

import openai
import requests

openai.api_key_path = "API.Key"

response = openai.Image.create(
    prompt="a cool cat drinking coffee",
    n=1,
    size="256x256",
)
image_url = response["data"][0]["url"]

resp = requests.get(image_url)
with open("image.png", "wb") as fp:
    fp.write(resp.content)

筆者測試生成一張 256 x 256 大小的圖片,大約需要 5 ~ 8 秒左右,速度還算蠻理想的。除了一般的圖像生成以外,還有圖片編輯 (Edits) 與變體 (Variations) 等功能,詳細用法請參考官方文件。這個 API 的計價方式是按圖收費,每張 256x 大小的圖片收取 0.016 鎂,而最大的 1024x 每張圖片則收取 0.02 鎂。

a cool cat drinking coffee

(Powered By OpenAI DALL-E)

結論

「如果將如此龐大的語言模型直接開源,將會很難限制使用者的不當用途。但如果語言模型的存取限制太高,這樣一來,普羅大眾將很難受益於這項先進科技。因此其中一個可行的方法是透過 API 的形式提供服務,由 API Provider 來負責監控不當使用的情況。」以上描述來自 InstructGPT 的論文。筆者在很大程度上認同這個觀點,尤其是這篇論文發表的時間是 2022 年 3 月,當時 ChatGPT 甚至都還沒發表。

直到今年 2023 年 3 月初,ChatGPT API 才正式上線。雖然 API 的形式依然留有許多問題,例如無法連網的裝置、使用者的隱私問題等等。但是其低廉的價格與存取的便利性,確實讓我們這些小型開發者大大受益。讓我們可以專注在上層的應用開發,而不需要煩惱硬體設備不足的問題,只要保持網路暢通即可!

以下是引自 InstructGPT 論文的原文:

If these models are open-sourced, it becomes challenging to limit harmful applications in these and other domains without proper regulation. On the other hand, if large language model access is restricted to a few organizations with the resources required to train them, this excludes most people from access to cutting-edge ML technology. Another option is for an organization to own the end-to-end infrastructure of model deployment, and make it accessible via an API. This allows for the implementation of safety protocols like use case restriction (only allowing the model to be used for certain applications), monitoring for misuse and revoking access to those who misuse the system, and rate limiting to prevent the generation of large-scale misinformation.

參考

本文所提及的價格皆為撰稿當下的資訊,收費標準時常變動,請以官方網站公佈的價目為主。


上一篇
LLM Note Day 3 - ChatGPT
下一篇
LLM Note Day 5 - 貓貓塔羅
系列文
LLM 學習筆記33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言